feat(mgmt-agent): add node-health detection and labeling controller (AROSLSRE-1588) - #6284
feat(mgmt-agent): add node-health detection and labeling controller (AROSLSRE-1588)#6284raelga wants to merge 14 commits into
Conversation
|
[APPROVALNOTIFIER] This PR is APPROVED This pull-request has been approved by: raelga The full list of commands accepted by this bot can be found here. The pull request process is described here DetailsNeeds approval from an approver in each of these files:
Approvers can indicate their approval by writing |
|
/retitle WIP feat(mgmt-agent): add generic node-healer controller (AROSLSRE-1588) |
|
/retitle WIP - DO NOT REVIEW YET - feat(mgmt-agent): add generic node-healer controller (AROSLSRE-1588) |
There was a problem hiding this comment.
Pull request overview
Adds a new mgmt-agent “node-healer” controller that watches kubelet Pod Events for configured failure signatures and, when thresholds/dwell are met for allow-listed nodes, performs a safety-gated remediation workflow (cordon → drain → delete) to trigger AKS reprovisioning. The controller is configured via a watched ConfigMap (hot-reload) and ships disabled + dry-run by default, with new Prometheus metrics and unit test coverage.
Changes:
- Introduces the
nodehealercontroller package (config parsing/validation, signal tracking, evaluation, remediation, and metrics) plus unit tests. - Wires the controller into
mgmt-agentleader election, adds Helm templates/values, default ConfigMap, and RBAC needed to cordon/drain/delete nodes. - Regenerates Helm golden fixture to include the new ConfigMap and deployment args.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| mgmt-agent/zz_fixture_TestHelmTemplate_dev_westus3_mgmt_1_mgmt_agent.yaml | Updated Helm golden fixture to include node-healer ConfigMap, args, and RBAC deltas. |
| mgmt-agent/values.yaml | Adds default node-healer chart values (disabled + dry-run default config). |
| mgmt-agent/pkg/controller/nodehealer/config.go | Implements YAML config model, defaults, compilation (regex), and validation. |
| mgmt-agent/pkg/controller/nodehealer/config_test.go | Unit tests for config parse/override/validation/allow-list behavior. |
| mgmt-agent/pkg/controller/nodehealer/consts.go | Shared constants for patch fieldManager/annotations (and related controller constants). |
| mgmt-agent/pkg/controller/nodehealer/controller.go | Core controller loop (queue, resync, evaluation, remediation orchestration, hot-reload). |
| mgmt-agent/pkg/controller/nodehealer/detector.go | Node evaluation logic (safety gating + detector firing → remediation selection). |
| mgmt-agent/pkg/controller/nodehealer/detector_test.go | Unit tests for evaluation guards and firing/remediation behavior. |
| mgmt-agent/pkg/controller/nodehealer/metrics.go | Prometheus metrics definitions + registration helper. |
| mgmt-agent/pkg/controller/nodehealer/remediation.go | Remediation engine (cordon/drain/delete, gating, events/metrics). |
| mgmt-agent/pkg/controller/nodehealer/remediation_test.go | Unit tests for remediation actions (including dry-run and pod filtering). |
| mgmt-agent/pkg/controller/nodehealer/signals.go | Event-informer signal ingestion + sliding-window tracking logic. |
| mgmt-agent/pkg/controller/nodehealer/signals_test.go | Unit tests for tracker window/dwell/forget semantics. |
| mgmt-agent/deploy/templates/node-healer-config.yaml | New Helm template to render the watched node-healer ConfigMap. |
| mgmt-agent/deploy/templates/deployment.yaml | Adds node-healer configmap/key args to mgmt-agent deployment. |
| mgmt-agent/deploy/templates/clusterrole.yaml | Expands RBAC to support node patch/delete and pod delete for drain. |
| mgmt-agent/cmd/options.go | Wires node-healer informers, configmap watcher, recorder, and controller startup under leader election. |
| mgmt-agent/cmd/cmd.go | Updates CLI help text documenting the new controller. |
Comments suppressed due to low confidence (1)
mgmt-agent/pkg/controller/nodehealer/remediation.go:201
- The
remediationparameter is unused in drain(), which is likely to be reported by the enabled golangci-lintunusedlinter. Use it or rename it to_.
func (r *Remediator) drain(ctx context.Context, name string, dryRun bool, remediation string) error {
37e547e to
9843b89
Compare
9843b89 to
6e81bd9
Compare
|
Force-pushed (WIP): rescoped this PR to phase 1 — detection + labelling only (per review feedback to ship the smallest useful increment first). Removed the cordon/drain/delete remediation path, the Confirmer/Scaler hooks, and the concurrency/cooldown/pre-scale safety machinery; the controller now sets/clears a Second push in this batch addresses the Copilot review: removed a dead const, made |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
mgmt-agent/pkg/controller/nodehealer/controller.go:125
- Tracker retention is computed only once from the initial config (Default) and never updated on hot-reload. If a ConfigMap reload increases a detector window or dwell, the tracker will keep pruning history too aggressively and firings/dwell timing will be incorrect until enough new history accumulates (or may never fire as expected). Update retention when applying a new config.
// SetConfig atomically replaces the configuration. The provided config must
// already be compiled and validated (use Parse). It also updates the tracker's
// retention so a hot-reload that widens a detector window/dwell does not prune
// older signals prematurely.
func (c *NodeHealer) SetConfig(cfg Config) {
c.config.Store(&cfg)
6e81bd9 to
55f59c8
Compare
55f59c8 to
c4f2c1d
Compare
|
Pushed c4f2c1d addressing the latest Copilot review round (all in-scope):
Build, vet, and |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (1)
mgmt-agent/deploy/templates/clusterrole.yaml:17
- The node-healer uses a MergePatch to modify nodes (Nodes().Patch), so the ClusterRole does not appear to need the broader 'update' verb on nodes. Dropping it would reduce privileges (least-privilege) while still supporting the current implementation.
verbs:
- get
- list
- watch
- patch
- apiGroups:
|
@copilot Both suppressed comments on this review were right, and they were the same bug in two places. Fixed. The success timestamp is a Pod condition stamped by the kubelet's own clock, so skew can place it ahead of the controller's clock. Both comparisons on it were signed, so a future timestamp read as an arbitrarily fresh success. I confirmed it before changing anything, by writing the test first against a replay of the real wedged node from the incident. With a success dated an hour ahead, It is also worse than it looks from the Both sites now measure the distance in absolute terms. Skew inside the window is ordinary and still counts as a success, beyond the window the timestamp is not usable evidence in either direction and is dropped. Two regression tests cover it, one at the decision level and one on the pruning path. Landed in No further action or commit needed from you, this is resolved. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.
Suppressed comments (2)
mgmt-agent/pkg/controller/nodehealth/labeler.go:91
Snapshot.Reason(the detector’s human-readable explanation) is populated indetectors.Decidebut never surfaced: the node annotation currently records onlysnap.ReasonString()(evidence counts) and the detector name. This makes theReasonfield effectively dead and leaves operators without the stable explanation on the node itself.
Consider including snap.Reason in the reason annotation when it’s available (while keeping the evidence snapshot), e.g. "<detector reason>; <evidence snapshot>".
reason := snap.ReasonString()
mgmt-agent/pkg/controller/nodehealth/metrics.go:32
- CONTRIBUTING.md requires before/after screenshots for metrics changes. This PR introduces a new
nodehealth_*Prometheus subsystem, so please add before/after screenshots (e.g., a/metricsscrape captured in a browser or a Prometheus query view) to the PR description to meet the repo PR standards.
&metrics.CounterOpts{
Subsystem: metricsSubsystem,
Name: "detections_total",
Help: "Number of times a detector caused a node to be marked wedged, counted when the node's detection record changes rather than on every re-evaluation.",
StabilityLevel: metrics.ALPHA,
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.
Suppressed comments (2)
mgmt-agent/pkg/controller/nodehealth/metrics.go:33
- This PR introduces new Prometheus metric series under the new nodehealth subsystem. Per CONTRIBUTING.md Pull Request Standards, metrics/observability changes require before/after screenshots in the PR description; the description currently includes a text scrape sample but not screenshots.
const metricsSubsystem = "nodehealth"
var (
detectionsTotal = metrics.NewCounterVec(
&metrics.CounterOpts{
Subsystem: metricsSubsystem,
Name: "detections_total",
Help: "Number of times a detector caused a node to be marked wedged, counted when the node's detection record changes rather than on every re-evaluation.",
StabilityLevel: metrics.ALPHA,
},
mgmt-agent/pkg/controller/nodehealth/config.go:41
- The Config.Enabled doc comment says the controller "records no state" when disabled, but the implementation explicitly records per-node success history while disabled (see controller.go:182-184 and recordPodSuccess). Please update the comment (or gate the recording) so operator-facing documentation matches real behavior.
// Enabled is a hard off switch. When false the controller still runs its
// informers but records no state, enqueues nothing, and takes no action.
Enabled bool `json:"enabled"`
Adds the pure decision core for detecting management-cluster nodes that are Ready but cannot start pods, so node lifecycle never sees them as broken and they silently swallow workloads. Detection is a pure function of observed state: no I/O, no clock of its own, so it is exhaustively table-testable. A detector declares which nodes it applies to, evaluates Events and Pod state over a window, and fires only on a sustained per-pod floor with no successful pod sandbox in the same window. That zero-success rule is the load-bearing discriminator between a hard wedge and a flap, since a flapping node still starts pods. The first detector covers the SWIFT v2 VF teardown seen in the uksouth and australiaeast incidents. Thresholds are hard-coded rather than configurable, so the logic and its values live in one testable file.
…E-1588) Drives the detector library from shared informers and reconciles level-driven off informer state, so a missed Event cannot strand a node in the wrong state. Events are correlated to their node by Source.Host and to their pod by UID. The controller detects and labels only. Mitigation of a labeled node is left to a separate controller, which keeps this one non-disruptive and safe to run in production before anything acts on its labels. Success is kept as a recorded per-node history rather than a point-in-time scan. A pod that started and was then deleted leaves nothing behind, so reading the absence of successful pods as zero would wedge a healthy but low-churn node. An empty history is treated as unknown, never as a false zero, so a restart cannot newly label a node until a full window has been observed. Labeling is idempotent and re-reads the node before writing, so a stale informer cache does not emit duplicate transitions or duplicate Events.
…SRE-1588) Starts the controller alongside the existing ones and exposes its operational switches, so it shares the process, the informer factory and the metrics endpoint rather than shipping as a separate deployment.
Adds the RBAC, the deployment wiring and the ConfigMap that carries the operational switches, so behaviour can be changed without a rebuild. RBAC is deliberately narrow: read-only on Nodes, Pods and Events, patch on Nodes for the label and annotations, and read on its own ConfigMap. No Secrets and no guest-cluster access, which bounds the blast radius to node metadata. The controller ships disabled. Enabling it is a separate, reversible step.
…1588) The decision core is a pure function, so it is covered exhaustively by table: each precondition, the floor and dwell boundaries, the zero-success rule, and the cases that must stay indeterminate rather than guess.
…SRE-1588) Covers the parts that are not pure: label and unlabel transitions including the stale-cache races, the steady state making no API call at all, retention of a label across a restart, the resync sweep, and the ConfigMap lifecycle (hot-enable, hot-disable, delete, invalid YAML, missing key).
…ce (AROSLSRE-1588) Replays state captured from a real wedged node during the uksouth incident, so a future edit that would have missed the actual outage fails here. The replay caught two false signals that the synthetic tests did not. Completed Job and CronJob pods keep a failed sandbox condition long after they ran fine, and the captured node carried enough of them to reach the floor on ordinary turnover alone. Separately, pods started before the fault keep reporting a healthy sandbox for as long as they run, so an unbounded success check would have suppressed detection on the very node that was broken. Identifiers from the capture are redacted, since no detector logic depends on them. The failure strings and the node's label and readiness shape are verbatim.
…records (AROSLSRE-1588)
|
@copilot Both suppressed comments reviewed.
// Enabled is a hard off switch. When false the controller enqueues
// nothing, reconciles nothing, and takes no action on any node. The pod
// informer handlers still write to the in-memory success map, but nothing
// reads it while disabled and it is discarded on the disabled->enabled
// transition, so a disabled controller carries no state forward.The discard is what makes this safe: Metrics screenshots. The CONTRIBUTING rule is about changes that affect dashboards or metric visualizations. This adds a brand new No further action or commit needed from you, this is resolved. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.
Suppressed comments (1)
mgmt-agent/pkg/controller/nodehealth/consts.go:21
- The ControllerName doc comment says it also feeds the event recorder component and log fields, but in the current implementation the event recorder component is hard-coded in cmd/options.go ("mgmt-agent-node-health") and controller logs use string literals. This comment is currently misleading; either wire the constant into those call sites or update the comment to match actual usage.
// ControllerName is the single source of truth for this controller's name.
// It feeds the workqueue name (which surfaces as a Prometheus label), the
// event recorder component, and log fields, so those never drift.
|
@copilot Correct, the comment overclaimed. I scoped the comment to what the constant actually does instead of wiring it into the recorder, because the recorder component is an externally visible Event source string and the field-manager identity it matches is what shows up in server-side apply conflicts. Collapsing the two onto one constant would tie those unrelated identities together for no gain. // ControllerName is the single source of truth for this controller's name.
// It feeds the workqueue name, which surfaces as a Prometheus label on the
// workqueue metrics, so that label never drifts from the controller.No further action or commit needed from you, this is resolved. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.
Suppressed comments (2)
mgmt-agent/pkg/controller/nodehealth/metrics.go:31
- This PR introduces new nodehealth_* Prometheus series. CONTRIBUTING.md requires before/after screenshots for metrics changes; the PR description currently includes text output, but should also include screenshots (e.g., a captured /metrics scrape or a Grafana panel) to meet the repo PR standards.
detectionsTotal = metrics.NewCounterVec(
&metrics.CounterOpts{
Subsystem: metricsSubsystem,
Name: "detections_total",
Help: "Number of times a detector caused a node to be marked wedged, counted when the node's detection record changes rather than on every re-evaluation.",
mgmt-agent/cmd/cmd.go:53
- The help text says the node-health controller “watches only SWIFT-v2 nodes”, but the controller’s Pod/Event handlers enqueue nodes by Pod.Spec.NodeName and Event.Source.Host without checking for the SWIFT-v2 label. While non-SWIFT nodes end up NotApplicable (no label changes), they can still be reconciled when enabled, so the description is misleading. Please reword this to say it labels only SWIFT-v2 nodes (scoped by the AKS label) rather than claiming it watches only them.
out-of-band consumer can act on it. It watches only SWIFT-v2 nodes and ships
disabled by default.
…ext (AROSLSRE-1588)
|
@copilot The I kept the scoping where it is rather than filtering at enqueue time. The applicability check runs against the live node from the informer cache, so a node that gains or loses the SWIFT-v2 label is re-evaluated on its next event, and a node that loses the label while carrying a stale wedged label still gets that label retired. Filtering at enqueue would make those stale labels unreachable, which is the specific failure the union-of-selectors sweep exists to prevent. On the metrics screenshots: this adds a new No further action or commit needed from you, this is resolved. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.
Suppressed comments (2)
mgmt-agent/pkg/controller/nodehealth/config.go:59
- Parse() uses yaml.Unmarshal, which silently ignores unknown fields. For an operational switch delivered via live-edited ConfigMap, that can turn typos (e.g.
enabeld: true) into a silent no-op (controller stays disabled) without surfacing an error. Consider using yaml.UnmarshalStrict so unknown keys fail fast and are logged by OnConfigMap().
if err := yaml.Unmarshal(data, &cfg); err != nil {
mgmt-agent/pkg/controller/nodehealth/controller.go:265
- recordPodSuccess() updates the successAt map even when the controller is disabled. Since pruning only happens in observation() (which is only called from syncHandler when enabled), leaving the controller disabled (the default) can let successAt grow without bound over time and adds unnecessary per-Pod-event work while "off".
func (c *Controller) recordPodSuccess(obj interface{}) {
if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok {
obj = tombstone.Obj
}
pod, ok := obj.(*corev1.Pod)
…othing while disabled (AROSLSRE-1588)
|
@copilot Both were real and both are fixed. Strict config parsing. Success map while disabled.
I checked both fixes are load-bearing by reverting them and re-running: without strict parsing the two unknown-key cases pass silently, and without the gate the disabled controller records The design doc in Azure/ARO-HCP#6298 carries the matching update. No further action or commit needed from you, this is resolved. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.
Suppressed comments (1)
mgmt-agent/pkg/controller/nodehealth/detectors/decide.go:109
- MaxWindow() is called from Controller.observation() while holding obsMu (controller.go:286+), and currently recomputes the value by calling Detector.Evaluate(nil, nil, time.Time{}). For signatureDetector this allocates a map on every call (signature_detector.go:88), which adds unnecessary allocations and extends the time obsMu is held on every reconcile. Cache the computed maximum window once (detectors are hard-coded) and have MaxWindow() return the cached value.
func MaxWindow() time.Duration {
var max time.Duration
for _, d := range registry {
if snap := d.Evaluate(nil, nil, time.Time{}); snap.Window > max {
max = snap.Window
…dow once (AROSLSRE-1588)
|
@copilot Fixed, and I took the underlying cause rather than caching the result. The real problem was that // Window is the detector's evaluation window. It is a fixed property of the
// detector, independent of any node, so callers that only need the window do
// not have to evaluate the detector to learn it.
Window() time.Duration
Verified with No further action or commit needed from you, this is resolved. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.
Suppressed comments (1)
mgmt-agent/pkg/controller/nodehealth/labeler.go:73
- The steady-state short-circuit only checks
annotationDetector, but the doc comment says the labeler also refreshes a “stripped” detection record. Ifreasonorobserved-atannotations are missing (butdetectoris still present), this will incorrectly treat the node as steady-state and never self-heal the missing annotations.
if node.Labels[labelKey] == labelValue && node.Annotations[annotationDetector] == detector {
logger.V(4).Info("node already labeled wedged by this detector (cache)")
return false, nil
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.
Suppressed comments (1)
mgmt-agent/pkg/controller/nodehealth/controller.go:439
- syncHandler() always calls gather() (listing all pods scheduled to the node and all pod Events emitted by the kubelet) before it determines that the node is actually a detector candidate. When the controller is enabled, enqueuePodObject/enqueueEventObject will enqueue nodes for all pods/events cluster-wide, so this can cause unnecessary per-node pod/event scans on nodes that will immediately resolve to DecisionNotApplicable.
Consider short-circuiting right after fetching the Node: if Decide(node, nil, nil, ...) returns DecisionNotApplicable, retire any stale label and return without calling gather(). This keeps the controller’s steady-state overhead aligned with the detector applicability rules.
events, pods, err := c.gather(name)
if err != nil {
return err
}
…owns (AROSLSRE-1588)
|
@copilot Agreed on the cost, fixed, though not by evaluating the detectors with nil inputs. The ownership question only reads the node, so it does not need a decision pass to answer. // AnyApplies reports whether any detector owns this node. It reads only the
// node, so a caller can answer the ownership question before doing the work of
// gathering the node's Pods and Events. Decide applies the same gate, so a node
// this rejects can only ever produce DecisionNotApplicable.
func AnyApplies(node *corev1.Node) bool
I kept the
No further action or commit needed from you, this is resolved. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.
Suppressed comments (1)
mgmt-agent/pkg/controller/nodehealth/metrics.go:55
- This PR introduces new Prometheus metric series under the
nodehealthsubsystem. PerCONTRIBUTING.mdPull Request Standards ("Include Screenshots for Graph/UI/Metrics/Performance Changes"), metrics changes require before/after screenshots in the PR description. The description currently includes a text scrape sample, but not screenshots; please add screenshots (e.g., of a/metricsscrape or a Grafana panel) before approval.
const metricsSubsystem = "nodehealth"
var (
detectionsTotal = metrics.NewCounterVec(
&metrics.CounterOpts{
Subsystem: metricsSubsystem,
Name: "detections_total",
Help: "Number of times a detector caused a node to be marked wedged, counted when the node's detection record changes rather than on every re-evaluation.",
StabilityLevel: metrics.ALPHA,
},
[]string{"detector"},
)
labelActionsTotal = metrics.NewCounterVec(
&metrics.CounterOpts{
Subsystem: metricsSubsystem,
Name: "label_actions_total",
Help: "Number of label/unlabel actions, by action and result.",
StabilityLevel: metrics.ALPHA,
},
[]string{"action", "result"},
)
wedgedNodes = metrics.NewGauge(
&metrics.GaugeOpts{
Subsystem: metricsSubsystem,
Name: "wedged_nodes",
Help: "Number of nodes currently carrying the wedged health label, as observed by the node-health controller. Reported as 0 while the controller is disabled.",
StabilityLevel: metrics.ALPHA,
},
)
)
Relates to AROSLSRE-1588
Implements the design in #6298 (
docs/controllers/node-health.md).What
Adds a node-health controller to
mgmt-agent. It watches management-cluster Nodes, Pods, and kubelet PodEventsthrough shared informers and, when a SWIFT-v2 node isReadybut wedged, labels the node (node-health.aro-hcp.azure.com/status=wedged, plus detector/reason/observed-at annotations) so a separate mitigation controller can act on it. This controller does detection plus a label/unlabel flow only, it does not cordon, drain, taint, or delete nodes; all disruptive mitigation is a separate controller.Detection is hard-coded and modular, not config-driven:
detectorover a shared toolkit: event-signature match, a sustained-storm floor, a per-pod dwell, and the load-bearing zero-successful-start discriminator that separates a hard wedge (VF gone) from a flap.decide(node, events, pods, now, observedSince, lastSuccessAt)with no API access, fed from a pod-by-node indexer and an event-by-node indexer keyed onEvent.Source.Host, so it is exhaustively table-tested. Events are correlated to a specific Pod byInvolvedObject.UID, never by name, and are used only to classify failing pods, never counted.PodReadyToStartContainers=Falsecondition has held past the dwell, so one long-stuck pod plus a burst of brand-new ones does not fire. Success is a recorded per-node history ofPodReadyToStartContainers=Truetransitions, so it survives Event and Pod garbage collection.Readybut every pod sandbox fails withno such network interface/network is unreachable/mtpnc is not ready/ DHCP-discover timeouts.kubernetes.azure.com/podnetwork-swiftv2-enabled=true, not a node-name allow-list.Runtime configuration is a single operational switch delivered via a watched ConfigMap (
mgmt-agent-node-health, hot-reload):enabled, a hard off switch. Detection thresholds are deliberately hard-coded as constants in each detector's Go file, not configured, so the whole detector stays a pure, exhaustively testable unit. The controller ships disabled by default.Why
The SWIFT-v2 delegated-NIC teardown leaves a node
Readywhile every new pod fails to get a sandbox, so the scheduler keeps placing work on a node that cannot run it. We hit this in production (australiaeast, uksouth) and mitigated it by hand. Labeling the node lets an out-of-band consumer drain or replace it automatically. Hard-coding the detection (instead of a config-driven engine) keeps the thresholds and the zero-success rule under code review and test, where they belong, and avoids a live production lever on a safety trigger.Testing
go build ./...,go vet ./...clean on themgmt-agentmodule.go test ./pkg/controller/nodehealth/...: table-drivendecide()cases (NotReady, no-detector, sub-floor, some-success flap, sustained wedge, per-pod dwell not met, one-old-plus-new-does-not-fire, event-UID correlation, recorded-success recovery), the recorded per-node success history (SuccessAt), plus config parse/validate and labeler apply/clear/idempotency.controller_test.go: white-box controller tests covering enable-gated enqueue, per-node observation and success recording across pod add/update/delete, hot-enable observation reset, success pruning past the max window, the informer index funcs, and end-to-endsyncHandlerwedge/recovery. Package coverage 55.8%, detectors 78.1%.make update-helm-fixtures).detector/reason/observed-atannotations +NodeHealthLabeledEvent), and recovery/unlabel.Metrics
This PR adds a
nodehealthPrometheus subsystem (all ALPHA), exposed on the existing mgmt-agent/metricsendpoint:nodehealth_detections_total{detector},nodehealth_label_actions_total{action,result}, andnodehealth_wedged_nodes.Before: no
nodehealth_*series exist (the subsystem is new in this PR).After (scraped from the leader mgmt-agent pod on the dev cluster, after the live wedge + recovery test):
The counters recorded exactly one label and one unlabel over the synthetic wedge/recovery cycle;
wedged_nodesis back to 0 after cleanup.The scrape above is the before/after evidence for this change. There is no dashboard screenshot to add: the
nodehealthsubsystem is new here, nothing consumes it yet (no dashboard, no recording rule, no alert), and the controller ships disabled in every environment, so there is no panel that looks different before and after. The change that wires these series into a dashboard is the one that carries screenshots.